Skip to main content

단일 책임 원칙

단일 책임 원칙(Single Responsibility Principle, SRP)는 함수, 클래스 혹은 리액트 컴포넌트는 변경해야할 이유가 단 하나만 있어야 함을 의미한다.
→ 즉, 하나의 컴포넌트는 하나의 작업이나 기능을 수행하는 것이 이상적이다.
장점
  • 코드 가독성 향상
  • 코드 유지보수성 향상
  • 코드 테스트 및 디버깅 쉬워짐

예시

블로그 포스트 컴포넌트는, 블로그 포스트 데이터 가져오기, 블로그 포스트 표시, 좋아요 기능 관리 까지 3가지 동작을 분리해서 리팩토링할 수 있다.
useFetchPost.tsx
// 블로그 포스트 데이터 가져오기
import { PostType } from "./types";
import { useEffect, useState } from "react";
import fetchPostById from "./fetchPostById";

const EmptyBlogPost = {
  id: "",
  title: "A title",
  summary: "A short summary of a title",
};
export const useFetchPost = (id: string): PostType => {
  const [post, setPost] = useState<PostType>(EmptyBlogPost);

  useEffect(() => {
    // @ts-ignore
    fetchPostById(id).then((post) => setPost(post));
  }, [id]);

  return post;
};
BlogPost.tsx
// 블로그 포스트 표시
import React from "react";
import { useFetchPost } from "./useFetchPost";
import { LikeButton } from "./LikeButton";

const BlogPost = ({ id }: { id: string }) => {
  const post = useFetchPost(id);

  return (
    <div>
      <h2>{post.title}</h2>
      <p>{post.summary}</p>
      <LikeButton />
    </div>
  );
};

export default BlogPost;
LikeButton.tsx
// 좋아요 기능 관리
import React, { useState } from "react";

export const LikeButton: React.FC = () => {
  const [isLiked, setIsLiked] = useState(false);

  const handleClick = () => {
    setIsLiked(!isLiked);
  };

  return <button onClick={handleClick}>{isLiked ? "Unlike" : "Like"}</button>;
};

중복 배제 원칙 (DRY)

중복 배제 원칙(Don’t Repeat Yourself, DRY)은 코드 안에서 중복을 줄이는 것이 목적이다. → 중복을 최소화하고, 재사용성을 높여준다.
장점
  • 코드 가독성 향상
  • 코드 유지보수성 향상
  • 코드 테스트 쉬워짐
  • 로직 중복으로 인해 발생하는 버그를 방지할 수 있음

예시

상품 리스트를 보여주는 ProductList 컴포넌트는 상품 이미지와 상품명, 가격, 장바구니 추가 버튼을 표시한다.
장바구니 리스트를 보여주는 CartList 컴포넌트 역시 상품 이미지, 상품명, 가격, 장바구니에서 제거 버튼을 표시한다.
구조가 매우 유사하다. 이런 경우, 중복된 구조를 하나의 컴포넌트로 만들고, 그것을 사용하도록 설계할 수 있다.
LineItem.tsx
// 상품 리스트를 보여주는 LineItem 컴포넌트
import { Product } from "../types";

const LineItem = ({
  product,
  performAction, // 버튼 클릭 시 호출되는 함수
  label, // 버튼 라벨
}: {
  product: Product;
  performAction: (id: string) => void;
  label: string;
}) => {
  const { id, image, name, price } = product;

  return (
    <div key={id} className="product">
      <img src={image} alt={name} />
      <div>
        <h2>{name}</h2>
        <p>${price}</p>
        <button onClick={() => performAction(id)}>{label}</button>
      </div>
    </div>
  );
};

export default LineItem;
이 컴포넌트를 활용해 상품 리스트를 보여주는 ProductList 컴포넌트와 장바구니 리스트를 보여주는 CartList 컴포넌트를 구현한다. 이렇게 구성하면, 상품 리스트 관련 변경은 한 곳(LineItem)에서만 일어나므로 버그가 발생할 가능성이 줄어든다. 그리고 LineItem에 새로운 기능을 추가한다면, 하나의 컴포넌트만 수정하면 된다.
기능 변경을 한 곳에서 해결할 수 있기 때문에, 유지보수가 단순해질 수 있다.

합성 활용하기 (컴포넌트 합성 원칙)

단순하고 재사용 가능한 컴포넌트를 조합하여 복잡한 UI를 구성하는 것이다.
리액트에서는 상속보다 합성(composition) 을 선호한다.

예시

UserDashboard.tsx
// 사용자 대시보드 컴포넌트
import { UserDashboardProps } from "./types";
import { UserProfile } from "./UserProfile";
import { FriendList } from "./FriendList";
import { PostList } from "./PostList";

function UserDashboard({ user, posts }: UserDashboardProps) {
  return (
    <div>
      <UserProfile user={user} />
      <FriendList friends={user.friends} />
      <PostList posts={posts} />
    </div>
  );
}

export default UserDashboard;
위 코드에서는 UserDashboard 컴포넌트가 UserProfile, FriendList, PostList 컴포넌트를 합성하여 사용자 대시보드를 구성하고 있다.
위와 같이 합성 컴포넌트를 사용했을 때의 장점
  • 관심사 분리
    • 컴포넌트의 여러 다른 영역을 UserProfile, FriendList, PostList 각 컴포넌트로 분리하여, 하나의 컴포넌트가 하나의 역할을 담당하게 되서, 유지보수가 쉬워진다.
  • 더 나은 가독성
    • 각 컴포넌트가 어떤 것을 렌더링할지 명확하게 보이기 때문에 가독성이 높다.
  • 높은 재사용성
    • UserProfile, FriendList, PostList 컴포넌트를 애플리케이션의 다른 영역에서도 재사용할 수 있기 때문에 중복을 줄일 수 있다.
합성 컴포넌트의 핵심은, 재사용이 가능한 작은 컴포넌트를 결합하여 큰 컴포넌트를 만든다는 것!

컴포넌트 설계 원칙의 결합

실전 코딩 상황에서는, 위와 같은 원칙들을 동시에 적용해야 한다. 헤더, 사이드바, 본문 영역의 상태와 움직임을 관리하는 Page 컴포넌트를 생각해보자.
Page.tsx
function Page({
  headerTitle, // 헤더 제목
  headerSubtitle, // 헤더 부제목
  sidebarLinks, // 사이드바 링크
  mainContent, // 본문 영역
  isLoading, // 로딩 상태
  onHeaderClick, // 헤더 클릭 시 호출되는 함수
  onSidebarLinkClick, // 사이드바 링크 클릭 시 호출되는 함수
}: PageProps) {
  return (
    <div>
      <header onClick={onHeaderClick}>
        <h1>{headerTitle}</h1>
        <h2>{headerSubtitle}</h2>
      </header>
      <aside>
        <ul>
          {sidebarLinks.map((link) => (
            <li key={link} onClick={() => onSidebarLinkClick(link)}>
              {link}
            </li>
          ))}
        </ul>
      </aside>
      {!isLoading && <main>{mainContent}</main>}
    </div>
  );
}
Page 컴포넌트는 많은 속성을 가진 prop으로 여러 가지 역할을 하며, 컴포넌트가 보통 5개 이상의 prop을 가진다면 분리가 필요하다.
→ 각 prop의 용도를 기억하기 쉽지 않을 수 있고, 잘못된 prop을 전달할 수 있다.
커다란 컴포넌트를 작게 나누는 방법은 다양하다. 한가지 방법은, 정보가 서로 관련되어 있다면, 하나의 그룹으로 묶고, 이 단위로 새로운 컴포넌트를 만든다.
Page.tsx (분리 후)
function Page({
  headerTitle,
  headerSubtitle,
  sidebarLinks,
  mainContent,
  isLoading,
  onHeaderClick,
  onSidebarLinkClick,
}: PageProps) {
  return (
    <div>
      {/* 헤더 컴포넌트 */}
      <Header
        title={headerTitle}
        subtitle={headerSubtitle}
        onClick={onHeaderClick}
      />
      {/* 사이드바 컴포넌트 */}
      <Sidebar links={sidebarLinks} onLinkClick={onSidebarLinkClick} />
      {/* 본문 컴포넌트 */}
      <Main isLoading={isLoading} content={mainContent} />
    </div>
  );
}
작은 컴포넌트로 나누고 합성을 해서, 훨씬 가독성이 좋아졌다.
하지만, 이 컴포넌트는 완벽하지 않다. Sidebar 컴포넌트나, Main 컴포넌트에 새로운 기능을 추가해야 해서 prop을 추가해야 한다면, Page 컴포넌트도 수정해야 한다.
그러면, Page 컴포넌트의 테스트도 늘어난 prop에 맞게 수정해야하므로, 상황이 상당히 복잡해진다.
HeaderSidebar 컴포넌트의 prop을 모두 표시하지말고, 각 컴포넌트의 인스턴스를 prop으로 전달받아 적절한 위치에 렌더링하도록 바꿔주자!
Page.tsx (prop 전달 방식 변경 후)
function Page({ header, sidebar, main }: PageProps) {
  return (
    <div>
      {header}
      {sidebar}
      {main}
    </div>
  );
}
MyPage.tsx
const MyPage = () => {
  return (
    <Page
      header={
        <Header
          title="My application"
          subtitle="Product page"
          onClick={() => console.log("toggle header")}
        />
      }
      sidebar={
        <Sidebar
          links={["Home", "About", "Contact"]}
          onLinkClick={() => console.log("toggle sidebar")}
        />
      }
      main={<Main isLoading={false} content={<div>The main</div>} />}
    />
  );
};
MyPage 컴포넌트는 Page 컴포넌트의 prop으로 각 컴포넌트의 인스턴스를 전달받아 렌더링하고 있다. Page 컴포넌트의 초기 버전은 지나치게 많은 역할을 맡고 있어서 prop 역시 늘어날 수 밖에 없었다. 그 후 개선한 버전에서도 많은 데이터를 각 계층에 전달해야하는 props drilling 문제가 있었다.
→ 이런 설계는 구성하기 복잡하고, 유지보수하기 어렵다.
정리단일 책임 원칙을 기반으로, 거대한 Page 컴포넌트를 작은 컴포넌트로 분리하였다.
→ 각 컴포넌트는 하나의 역할만 담당할 수 있게 된다.
그리고 합성을 통해 Page 컴포넌트가 분리한 서브 컴포넌트를 prop으로 전달받도록 수정했다.
→ props drilling 문제도 해결되었다.